FeedPostView.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. 'use client';
  2. import Link from 'next/link';
  3. import { useRouter } from 'next/navigation';
  4. import { useState } from 'react';
  5. import { Heart, MessageCircle, Share2, BadgeCheck, MoreHorizontal, Bookmark, Flag, Link as LinkIcon, Trash2, ArrowLeft } from 'lucide-react';
  6. import { fetchApi } from '@/lib/utils/client';
  7. import useAuth from '@/hooks/useAuth';
  8. import { formatDate, getDateTime } from '@/lib/utils/client';
  9. import { FeedPostDetail, FeedReply } from '@/types/feed/post';
  10. import {
  11. DropdownMenu,
  12. DropdownMenuContent,
  13. DropdownMenuItem,
  14. DropdownMenuTrigger,
  15. DropdownMenuSeparator
  16. } from '@/components/ui/dropdown-menu';
  17. import TagChip from '../../../_component/TagChip';
  18. import FeedLightbox from '../../../_component/FeedLightbox';
  19. import FeedReplyList from './FeedReplyList';
  20. import FeedReplyComposer from './FeedReplyComposer';
  21. type Props = {
  22. post: FeedPostDetail;
  23. initialReplies: FeedReply[];
  24. initialRepliesTotal: number;
  25. };
  26. export default function FeedPostView({ post, initialReplies, initialRepliesTotal }: Props) {
  27. const router = useRouter();
  28. const { loginCheck } = useAuth();
  29. const [likes, setLikes] = useState(post.likes);
  30. const [liked, setLiked] = useState(post.hasLike);
  31. const [bookmarked, setBookmarked] = useState(post.hasBookmark);
  32. const [busy, setBusy] = useState(false);
  33. const [replyTarget, setReplyTarget] = useState<FeedReply|null>(null);
  34. const [refreshKey, setRefreshKey] = useState<number>(0);
  35. const [lightboxIndex, setLightboxIndex] = useState<number|null>(null);
  36. const authorDisplay = post.authorName || post.authorSID || '알 수 없음';
  37. const avatarInitial = (authorDisplay.charAt(0) || '?').toUpperCase();
  38. const imageUrls = post.images.map(i => i.url);
  39. const handleLike = async () => {
  40. if (!loginCheck() || busy) {
  41. return;
  42. }
  43. setBusy(true);
  44. try {
  45. const res = await fetchApi<{ hasLike: boolean; likes: number }>(`/api/feed/post/${post.postID}/like`, {
  46. method: 'POST',
  47. silent: true
  48. });
  49. if (res.success && res.data) {
  50. setLiked(res.data.hasLike);
  51. setLikes(res.data.likes);
  52. }
  53. } catch (err) {
  54. console.error(err);
  55. } finally {
  56. setBusy(false);
  57. }
  58. };
  59. const handleBookmark = async () => {
  60. if (!loginCheck() || busy) {
  61. return;
  62. }
  63. setBusy(true);
  64. try {
  65. const res = await fetchApi<{ hasBookmark: boolean; bookmarks: number }>(`/api/feed/post/${post.postID}/bookmark`, {
  66. method: 'POST',
  67. silent: true
  68. });
  69. if (res.success && res.data) {
  70. setBookmarked(res.data.hasBookmark);
  71. }
  72. } catch (err) {
  73. console.error(err);
  74. } finally {
  75. setBusy(false);
  76. }
  77. };
  78. const handleShare = async () => {
  79. const url = `${window.location.origin}/feed/post/${post.postID}`;
  80. try {
  81. await navigator.clipboard.writeText(url);
  82. alert('링크가 복사되었습니다.');
  83. } catch {
  84. prompt('링크를 복사하세요:', url);
  85. }
  86. };
  87. const handleDelete = async () => {
  88. if (!confirm('이 게시글을 삭제할까요?')) {
  89. return;
  90. }
  91. try {
  92. const res = await fetchApi(`/api/feed/post/${post.postID}`, {
  93. method: 'DELETE',
  94. silent: true
  95. });
  96. if (res.success) {
  97. router.push('/feed/all');
  98. router.refresh();
  99. }
  100. } catch (err) {
  101. console.error(err);
  102. }
  103. };
  104. const handleReplyTarget = (target: FeedReply) => {
  105. setReplyTarget(target);
  106. };
  107. const handleReplySubmitted = () => {
  108. setRefreshKey((k) => k + 1);
  109. };
  110. const handleBack = () => {
  111. if (window.history.length > 1) {
  112. router.back();
  113. } else {
  114. router.push('/feed/all');
  115. }
  116. };
  117. return (
  118. <>
  119. <nav className="feed__page-header" aria-label="페이지 헤더">
  120. <button type="button" className="feed__page-back" onClick={handleBack} aria-label="뒤로 가기">
  121. <ArrowLeft size={20} />
  122. </button>
  123. <h1 className="feed__page-title">게시물</h1>
  124. </nav>
  125. <article className="feed__post">
  126. <header className="feed__post-header">
  127. {post.authorSID ? (
  128. <Link href={`/user/${post.authorSID}`} className="feed__post-avatar" aria-label={`${authorDisplay} 프로필`}>
  129. {post.authorThumb ? (
  130. <img src={post.authorThumb} alt={authorDisplay} />
  131. ) : (
  132. <span className="feed__post-avatar-fallback">{avatarInitial}</span>
  133. )}
  134. </Link>
  135. ) : (
  136. <span className="feed__post-avatar">
  137. <span className="feed__post-avatar-fallback">{avatarInitial}</span>
  138. </span>
  139. )}
  140. <div className="feed__post-meta">
  141. <div className="feed__post-author-row">
  142. {post.authorSID ? (
  143. <Link href={`/user/${post.authorSID}`} className="feed__post-author">{authorDisplay}</Link>
  144. ) : (
  145. <span className="feed__post-author">{authorDisplay}</span>
  146. )}
  147. {post.isCreator && (
  148. <span className="feed__post-badge" title="크리에이터">
  149. <BadgeCheck size={14} fill="currentColor" stroke="#fff" strokeWidth={2.5} />
  150. </span>
  151. )}
  152. </div>
  153. <div className="feed__post-meta-row">
  154. <span title={getDateTime(post.createdAt)}>{formatDate(post.createdAt)}</span>
  155. <span>· 조회 {post.views.toLocaleString()}</span>
  156. </div>
  157. </div>
  158. <DropdownMenu>
  159. <DropdownMenuTrigger asChild>
  160. <button type="button" className="feed__post-more" aria-label="더보기">
  161. <MoreHorizontal size={20} />
  162. </button>
  163. </DropdownMenuTrigger>
  164. <DropdownMenuContent align="end">
  165. <DropdownMenuItem onClick={handleBookmark}>
  166. <Bookmark size={14} fill={bookmarked ? 'currentColor' : 'none'} className="mr-2" />
  167. {bookmarked ? '저장 해제' : '저장'}
  168. </DropdownMenuItem>
  169. <DropdownMenuItem onClick={handleShare}>
  170. <LinkIcon size={14} className="mr-2" />
  171. 링크 복사
  172. </DropdownMenuItem>
  173. {post.isOwner && (
  174. <>
  175. <DropdownMenuSeparator />
  176. <DropdownMenuItem onClick={handleDelete} className="text-red-600">
  177. <Trash2 size={14} className="mr-2" />
  178. 삭제
  179. </DropdownMenuItem>
  180. </>
  181. )}
  182. {!post.isOwner && (
  183. <DropdownMenuItem>
  184. <Flag size={14} className="mr-2" />
  185. 신고
  186. </DropdownMenuItem>
  187. )}
  188. </DropdownMenuContent>
  189. </DropdownMenu>
  190. </header>
  191. <div className="feed__post-body">
  192. <p className="feed__post-content">{post.content}</p>
  193. </div>
  194. {post.images.length > 0 && (
  195. <div className={`feed__post-gallery feed__post-gallery--count-${Math.min(post.images.length, 4)}`}>
  196. {post.images.slice(0, 4).map((image, index) => (
  197. <button
  198. type="button"
  199. key={image.url}
  200. className="feed__post-gallery-item"
  201. onClick={() => setLightboxIndex(index)}
  202. aria-label={`이미지 ${index + 1} 크게 보기`}
  203. >
  204. <img src={image.url} alt={`이미지 ${index + 1}`} loading="lazy" />
  205. {index === 3 && post.images.length > 4 && (
  206. <span className="feed__post-gallery-more">+{post.images.length - 4}</span>
  207. )}
  208. </button>
  209. ))}
  210. </div>
  211. )}
  212. {post.tags.length > 0 && (
  213. <div className="feed__post-tags">
  214. {post.tags.map((tag) => (
  215. <TagChip key={tag.slug} name={tag.name} slug={tag.slug} />
  216. ))}
  217. </div>
  218. )}
  219. <div className="feed__post-actions">
  220. <button type="button" className={`feed__post-action${liked ? ' feed__post-action--active' : ''}`} onClick={handleLike} aria-pressed={liked} aria-label="좋아요">
  221. <Heart size={18} fill={liked ? 'currentColor' : 'none'} />
  222. <span>{likes.toLocaleString()}</span>
  223. </button>
  224. <span className="feed__post-action feed__post-action--stat" aria-label="댓글 수">
  225. <MessageCircle size={18} />
  226. <span>{post.comments.toLocaleString()}</span>
  227. </span>
  228. <button type="button" className="feed__post-action" onClick={handleShare} aria-label="공유">
  229. <Share2 size={18} />
  230. </button>
  231. </div>
  232. </article>
  233. <FeedReplyComposer
  234. postID={post.postID}
  235. replyTarget={replyTarget}
  236. onClearReplyTarget={() => setReplyTarget(null)}
  237. onSubmitted={handleReplySubmitted}
  238. />
  239. <FeedReplyList
  240. postID={post.postID}
  241. initialReplies={initialReplies}
  242. initialTotal={initialRepliesTotal}
  243. onReply={handleReplyTarget}
  244. refreshKey={refreshKey}
  245. />
  246. {lightboxIndex !== null && (
  247. <FeedLightbox
  248. urls={imageUrls}
  249. startIndex={lightboxIndex}
  250. onClose={() => setLightboxIndex(null)}
  251. />
  252. )}
  253. </>
  254. );
  255. }